PostgreSQL 事务、MVCC、Snapshot 与 Tuple 可见性
从 Tuple 版本生命周期出发,串联事务 ID、Snapshot、可见性判断、长事务、VACUUM、读副本冲突与 pgx 事务边界。
第 9 章:事务、MVCC、Snapshot 与 Tuple 可见性
技术基线:PostgreSQL 18;兼顾 PostgreSQL 14—18。Go 示例使用
github.com/jackc/pgx/v5与pgxpool。 阅读前提:已掌握 PostgreSQL Page、Heap Tuple、Buffer、WAL 与普通索引的基础知识。 本章唯一主线:一个 Tuple 版本如何产生、何时可见、何时失效、何时能被回收,以及这条链如何影响高性能、高并发和高可用。
1. 本章定位
学习 MVCC 最容易陷入一个误区:把 xmin、xmax、Snapshot、隔离级别、VACUUM、长事务和副本冲突分别背成互不相关的知识点。实际上,它们都在回答同一组问题:
- 谁创建了这个 Tuple 版本?
- 观察者拿着什么 Snapshot?
- 谁结束了这个 Tuple 版本?
- 还有没有任何 Snapshot 可能看到它?
只要沿着这四个问题推导,就能把本章全部内容串成一条因果链:
事务写入
-> 产生带 xmin/xmax 的 Tuple 版本
-> 查询取得 Snapshot
-> 可见性函数选择版本
-> COMMIT/ROLLBACK 决定事务效果
-> 旧版本等待所有相关 Snapshot 离开
-> VACUUM 才能回收
-> 版本保留时间决定性能、并发和副本行为
本章解决以下生产问题:
- 为什么一个事务已经提交,另一个事务仍可能看不到它;
- 为什么普通
SELECT通常不阻塞UPDATE,但两个写者仍会互相等待; - 为什么
UPDATE和DELETE会留下旧版本; - 为什么只读长事务也会导致表和索引膨胀;
- 为什么
idle in transaction会同时占连接、持锁并钉住清理地平线; - 为什么 Read Committed 中两次相同查询可能返回不同结果;
- 为什么 Standby 长查询可能被取消,或者反过来拖累 Primary 的 VACUUM;
- 为什么客户端收到
COMMIT网络错误后,不能简单断言事务未提交。
本章承接第 3 章的 Page/Tuple/HOT,为后续章节建立基础:
- 第 10 章:隔离级别、SSI、并发异常和完整事务重试;
- 第 11 章:行锁、死锁、热点行和任务队列;
- 第 12 章:VACUUM、Freeze、Wraparound 和 Bloat;
- 第 13 章:WAL、Checkpoint、Crash Recovery 和提交路径。
本章不会深入展开 SSI 危险结构、锁模式矩阵、Freeze 算法和 WAL 刷盘协议,但会明确它们与 MVCC 的边界。
2. 可验证的学习目标
完成本章后,你应当能够:
- 根据 Tuple 的
xmin/xmax、事务状态和 Snapshot,推导该版本是否可见。 - 区分 Virtual Transaction ID、Transaction ID、Full Transaction ID、subxid 和 Command ID。
- 画出
INSERT、UPDATE、DELETE对 Heap Tuple 的物理影响。 - 解释“事务已经提交”为什么不等于“所有既有 Snapshot 都能看到”。
- 复现 Read Committed 的语句级 Snapshot 和 Repeatable Read 的事务级 Snapshot。
- 解释当前事务为什么能看到自己的先前写入,以及 Command ID 在其中的作用。
- 使用
pg_stat_activity.backend_xmin判断长事务是否阻止 VACUUM 推进。 - 使用
pageinspect观察更新链、删除标记和 Hint Bits,并区分“物理存在”与“SQL 可见”。 - 从长 Snapshot 推导 Heap/Index 膨胀、Buffer 读放大、I/O、WAL 和 P99 的变化。
- 说明 MVCC 能解决哪些读写冲突,又不能解决哪些写写竞争。
- 说明 Replica Snapshot、Recovery Conflict、
hot_standby_feedback与 Primary 膨胀之间的取舍。 - 使用 pgx 编写包含
BeginTx、事务选项、defer Rollback、Commit 错误处理和context的事务模板。 - 为提交结果不确定的业务设计幂等键、唯一约束和结果核对流程。
3. 核心术语:不要背词表,要看它们在链路中的角色
本章术语可以分成四组。第一组属于写者,第二组属于Tuple 版本,第三组属于观察者,第四组属于清理与复制。
| 角色 | 术语 | 准确定义 | 在主线中的作用 | 最容易混淆 |
|---|---|---|---|---|
| 写者 | Virtual Transaction ID, VXID | backend 事务开始后即可拥有的临时事务标识 | 在尚未分配真实 XID 时标识当前事务并参与锁协调 | PID、持久 XID |
| 写者 | Transaction ID, XID | 集群级 32 位事务标识,通常在事务首次写入时分配 | 写入 Tuple 的 xmin/xmax,并由 pg_xact 记录最终状态 | Full XID、业务 ID |
| 写者 | Full Transaction ID | epoch 与 32 位 XID 组合形成的逻辑 64 位标识,SQL 类型为 xid8 | 在更长时间范围表达事务顺序 | Tuple 系统列 xmin 的 xid |
| 写者 | subxid | 写子事务获得的事务 ID | 让 Savepoint 内的写入可以局部回滚 | 顶层 XID |
| 写者 | Command ID, CID | 同一事务内命令的顺序号 | 区分“自己的先前写入”和“当前扫描之后才产生的写入” | XID、SQL 文本序号 |
| 版本 | Tuple Version | 同一逻辑行在 Heap 中的一个物理版本 | MVCC 实际选择的对象 | 业务上的一行、Index Tuple |
| 版本 | Tuple xmin | 创建该物理版本的事务 ID | 回答“谁把它生出来” | Snapshot xmin |
| 版本 | Tuple xmax | 删除、更新或锁定该版本的事务/MultiXact 标识 | 回答“谁试图结束或锁定它” | Snapshot xmax |
| 版本 | ctid | 当前物理位置,旧版本更新后通常指向后继版本 | 串联更新链;不是稳定业务主键 | 主键、永久行 ID |
| 观察者 | Snapshot | 查询用于判断事务效果是否发生在观察点之前的一组边界和活跃事务信息 | 决定观察者能看到哪个版本 | 备份快照、文件系统快照 |
| 观察者 | Snapshot xmin | Snapshot 取得时仍可能活跃的最小顶层 XID 下界 | 划分“更老事务”和边界区间 | Tuple xmin |
| 观察者 | Snapshot xmax | Snapshot 取得时下一个尚未分配的 XID | >= xmax 的事务对该 Snapshot 都属于未来 | Tuple xmax |
| 观察者 | xip | 取得 Snapshot 时仍活跃的顶层事务集合 | 即使这些事务后来提交,旧 Snapshot 仍把它们视为当时未完成 | 所有连接、subxid 列表 |
| 清理 | pg_xact | 保存近期事务最终状态的提交状态日志 | 在 Hint Bits 不足时判断 committed/aborted | WAL、业务审计表 |
| 清理 | Hint Bits | 缓存在 Tuple 头中的事务状态判断结果 | 减少后续访问 pg_xact 的成本 | Visibility Map |
| 清理 | backend_xmin | backend 当前 Snapshot 对全局清理地平线的贡献 | 判断该 backend 是否阻止旧版本回收 | backend_xid |
| 复制 | Replica/Recovery Snapshot | Standby 在 WAL 回放一致点上建立的查询视图 | 决定副本查询可见数据,并可能与回放清理记录冲突 | Primary 上导出的 Snapshot |
命名陷阱:Tuple 的
xmin/xmax描述一个版本的创建者和结束者;Snapshot 的xmin/xmax描述观察者的事务时间边界。名称相同,角色完全不同。
4. 整体心智模型:版本生命周期

4.1 本章的“可见性方程”
把复杂实现先压缩成一句话:
Visible(tuple, snapshot)
= 创建事务对该 Snapshot 已经生效
AND
结束事务对该 Snapshot 尚未生效
还要增加两个修正条件:
xmax可能只是锁或 MultiXact,不能机械地视为删除。- 若创建者或结束者是当前事务,还必须用
cmin/cmax与curcid判断命令先后。
后面所有细节都只是把这句话具体化。
4.2 ACID 在这条链中的位置
| 属性 | PostgreSQL 中与本章最相关的实现边界 | 容易产生的误解 |
|---|---|---|
| Atomicity | 写入先形成物理版本,COMMIT/ROLLBACK 决定这些版本是否成为有效事务效果 | 回滚会立即把每个 Page 原地恢复 |
| Consistency | 数据库保证已声明约束和事务语义;跨行、跨表业务不变量仍需正确 SQL、锁或隔离策略 | 把两条 SQL 放进事务就自动保证所有业务规则 |
| Isolation | Snapshot 决定读哪个版本;锁和 SSI 处理写写冲突与更高层并发异常 | MVCC 可以消除所有阻塞 |
| Durability | WAL、刷盘策略、复制确认和恢复能力决定提交能抵抗哪些故障 | 当前节点可见就等于副本和备份都已有数据 |
4.3 为什么三个工程维度都从同一模型推导
| 维度 | 从版本生命周期推导出的核心问题 |
|---|---|
| 高性能 | 不可见版本有多少、保留多久、查询为找到可见版本要扫描多少 Page |
| 高并发 | 读者能否读取旧版本而不等待写者,以及多个写者是否竞争同一逻辑行 |
| 高可用 | COMMIT 到底在哪些节点持久化、Standby 回放能否推进、故障转移后事务是否仍存在 |
5. 贯穿案例与“四问法”
后续所有原理都围绕一行账户余额展开:
CREATE TABLE account_balance (
account_id bigint PRIMARY KEY,
balance_cents bigint NOT NULL CHECK (balance_cents >= 0),
note text NOT NULL
);
假设它经历以下版本:
T1: (account_id=1, balance=10000, note='v1')
T2: (account_id=1, balance= 9000, note='v2')
最后被删除
分析任何查询时都按以下顺序:
Q1 谁创建这个版本? -> Tuple.xmin
Q2 观察者处在哪个时间视角? -> Snapshot xmin/xmax/xip
Q3 谁结束这个版本? -> Tuple.xmax + infomask + 事务状态
Q4 还有谁可能看到它? -> backend_xmin / 复制反馈 / 其他 horizon
5.1 本章常用观察工具
这些函数和系统视图不是零散工具,而是分别回答四个问题:
-- Q1:当前事务是否已经获得真实 XID
SELECT pg_current_xact_id_if_assigned();
-- 会主动分配 xid8;只在确实需要时调用
SELECT pg_current_xact_id();
-- Q2:当前语句的 Snapshot
SELECT pg_current_snapshot() AS snap,
pg_snapshot_xmin(pg_current_snapshot()) AS snap_xmin,
pg_snapshot_xmax(pg_current_snapshot()) AS snap_xmax;
SELECT * FROM pg_snapshot_xip(pg_current_snapshot());
-- 判断某个顶层 xid8 是否在 Snapshot 取得前已经完成。
-- 不要把 subxid 传给该函数。
SELECT pg_visible_in_snapshot($1::xid8, pg_current_snapshot());
-- Q1/Q3:观察近期顶层事务的最终状态
SELECT pg_xact_status($1::xid8);
-- Q1/Q3:观察 SQL 可见版本的系统列
SELECT ctid, xmin, xmax, cmin, cmax, *
FROM account_balance
WHERE account_id = $1;
-- Q4:观察谁在持有清理地平线
SELECT pid,
application_name,
state,
xact_start,
backend_xid,
backend_xmin,
wait_event_type,
wait_event,
left(query, 200) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
5.2 版本差异与安全边界
| 能力 | PG14 | PG15 | PG16 | PG17 | PG18 |
|---|---|---|---|---|---|
xid8、pg_snapshot、pg_current_xact_id() | 支持 | 支持 | 支持 | 支持 | 支持 |
idle_in_transaction_session_timeout | 支持 | 支持 | 支持 | 支持 | 支持 |
transaction_timeout | — | — | — | [PG17+] | 支持 |
| RC/RR 的 MVCC 语义 | 稳定 | 稳定 | 稳定 | 稳定 | 稳定 |
| 异步 I/O | — | — | — | — | [PG18] 改善部分 I/O 路径,不改变可见性语义 |
安全注意事项:
pg_current_xact_id()会让原本可能不分配 XID 的只读事务获得真实 XID;监控优先使用pg_current_xact_id_if_assigned()。- 系统列适合诊断,不适合充当永久业务版本号、审计 ID 或跨集群接口。
pageinspect读取物理 Page,不应用普通 SQL 的可见性过滤,只应在受控实验或管理员诊断中使用。- 不要为了观察旧版本而在生产关闭 autovacuum、
fsync或full_page_writes。
6. 主线一:写者如何产生 Tuple 版本
6.1 从 BEGIN 到真实 XID
事务开始时,backend 首先拥有 VXID。它可能执行很多只读语句,却始终没有真实 XID:
BEGIN;
SELECT pg_current_xact_id_if_assigned(); -- 通常为 NULL
SELECT count(*) FROM account_balance;
SELECT pg_current_xact_id_if_assigned(); -- 仍可能为 NULL
ROLLBACK;
当事务首次写入,或显式调用会分配 XID 的函数时,PostgreSQL 才从集群全局计数器取得真实 XID:
BEGIN;
SELECT pg_current_xact_id_if_assigned(); -- NULL
UPDATE account_balance
SET note = 'changed'
WHERE account_id = 1;
SELECT pg_current_xact_id_if_assigned(); -- 已有 xid8
ROLLBACK;
这样设计的原因不是语义需要,而是资源效率:32 位 XID 会循环使用,事务越少无意义地分配 XID,冻结和回绕治理压力越低。
XID、Full XID 与 VXID 的关系
VXID = backend-local 临时事务身份
XID = 写入 Tuple、记录提交状态的 32 位集群事务号
Full XID / xid8 = epoch + XID,用于更长范围的顺序表达
Tuple 系统列 xmin/xmax 的底层类型仍是 xid。不要把 xmin::text::bigint 当作永久单调业务版本;它会回绕,恢复、逻辑复制和迁移也会改变其语义。
6.2 INSERT:版本从何而来
假设事务 X101 插入初始余额:
INSERT INTO account_balance(account_id, balance_cents, note)
VALUES (1, 10000, 'v1');
Heap 中形成一个物理版本:
T1:
xmin = X101
xmax = Invalid
ctid = self
data = (1, 10000, 'v1')
此时:
- 对
X101自己,后续命令可以看到 T1; - 对其他事务,T1 是否可见取决于
X101的状态和观察者 Snapshot; - 若
X101回滚,T1 仍可能物理存在,但逻辑上永远不是正常可见版本,等待 VACUUM 回收。
串联结论:INSERT 并不是“把一行变成全局可见”,而是“创建一个带创建者身份的候选版本”。是否可见要等读者拿 Snapshot 后再判断。
6.3 UPDATE:结束旧版本,再创建新版本
事务 X102 把余额改为 9000:
UPDATE account_balance
SET balance_cents = 9000,
note = 'v2'
WHERE account_id = 1;
UPDATE 的核心不是原地覆盖,而是:
旧版本 T1:
xmin = X101
xmax = X102
ctid -> T2
data = (..., 10000, 'v1')
新版本 T2:
xmin = X102
xmax = Invalid
ctid = self
data = (..., 9000, 'v2')
因此同一逻辑行可以同时存在多个物理版本。哪个版本对某个查询可见,不由“当前表值”决定,而由该查询的 Snapshot 决定。
若满足 HOT 条件,索引可以继续指向更新链根部,Heap 内通过 ctid 追到后继版本,从而减少索引维护。HOT 能降低索引写放大,但不会取消 Heap 新版本,也不会绕过 Snapshot 和 VACUUM 安全边界。
6.4 DELETE:结束当前版本,不创建“空版本”
事务 X103 删除这行:
DELETE FROM account_balance
WHERE account_id = 1;
物理上通常只是把当前版本标记为由 X103 结束:
T2:
xmin = X102
xmax = X103
DELETE 不需要再创建一个代表“已删除”的业务版本。旧 Snapshot 仍可能读取 T1 或 T2;只有新的 Snapshot 在确认删除事务已于其观察点之前提交后,才不再返回该逻辑行。
6.5 为什么 xmax <> 0 不等于“已删除”
xmax 还可能表示:
- 仅锁定该 Tuple 的事务;
- 多个锁持有者组成的 MultiXact;
- 已回滚的 UPDATE/DELETE;
- 尚未提交的结束事务。
因此正确问题不是“xmax 是否非零”,而是:
这个 xmax 是否代表有效的更新/删除?
该事务是否对当前 Snapshot 已经生效?
物理诊断时必须结合 t_infomask/t_infomask2、事务状态和 Snapshot。
6.6 COMMIT、ROLLBACK、pg_xact 与 Hint Bits
COMMIT 并不会回头改写该事务触碰过的每个 Tuple。它主要把事务最终状态记录为 committed;ROLLBACK 则使其成为 aborted。
X101 INSERT 后 COMMIT
-> T1 的创建有效
X102 UPDATE 后 COMMIT
-> T1 的结束有效,T2 的创建有效
X102 UPDATE 后 ROLLBACK
-> T1 的结束无效,T2 的创建无效
当读取 Tuple 时,如果头部没有足够的缓存信息,可见性代码会查询 pg_xact;确认后可能把结果写入 Hint Bits,例如:
HEAP_XMIN_COMMITTED;HEAP_XMIN_INVALID;HEAP_XMAX_COMMITTED;HEAP_XMAX_INVALID。
Hint Bits 是读取路径的缓存,不是提交的权威记录。PostgreSQL 不在 COMMIT 时同步改写该事务创建过的所有 Tuple,因为那会产生巨大的随机写放大。
主线一小结:写者只负责产生版本和提交状态。它并不决定任何其他查询“此刻应该看到什么”;这个问题属于观察者的 Snapshot。
7. 主线二:读者如何选择可见版本
7.1 Snapshot 不是时间戳,而是一组事务边界
一个 SQL 可见的 pg_snapshot 通常形如:
xmin:xmax:xip1,xip2,...
假设某个 Snapshot 为:
100:105:101,103
可以这样理解:
| 事务 XID | Snapshot 视角 | 下一步 |
|---|---|---|
X < 100 | 取得 Snapshot 时已经结束 | 再查 committed 还是 aborted |
X = 101 或 103 | 取得 Snapshot 时仍活跃 | 对该 Snapshot 尚未生效 |
100 <= X < 105 且不在 xip | 取得 Snapshot 前已经结束 | 再查 committed 还是 aborted |
X >= 105 | Snapshot 之后才分配,属于未来 | 对该 Snapshot尚未生效 |
最重要的结论是:
一个事务后来提交,不会改变旧 Snapshot 对“它在取得 Snapshot 时仍然活跃”的历史判断。
这正是 Repeatable Read 能保持稳定视图的基础。
7.2 先判断创建,再判断结束
对任意 Tuple,始终按同一顺序判断。
第一步:创建者 xmin 是否已经生效
xmin为 Frozen:视为早已提交。xmin属于当前事务:用cmin与snapshot.curcid判断命令先后。xmin在xip中,或xmin >= snapshot.xmax:创建者对该 Snapshot 尚未完成,版本不可见。xmin已 committed 且不属于上述情况:创建有效,继续判断xmax。xmin已 aborted:版本不可见。
第二步:结束者 xmax 是否已经生效
前提是创建有效:
xmax无效、已回滚或仅代表锁:版本仍可见。xmax属于当前事务:用cmax与curcid判断删除/更新发生在当前扫描之前还是之后。xmax在xip中,或xmax >= snapshot.xmax:结束动作对该 Snapshot 尚未发生,旧版本仍可见。xmax已 committed 且发生在 Snapshot 之前:旧版本不可见。
可以把它记成:
先问:它是否被有效地“生出来”?
再问:它是否已经被有效地“结束”?
两个问题都必须站在当前 Snapshot 的历史视角回答。
7.3 用贯穿案例验证三个观察者
假设:
T1: xmin=X101, xmax=X102, balance=10000
T2: xmin=X102, xmax=X103, balance= 9000
观察者 S_old:在 X102 UPDATE 之前取得 Snapshot
- T1 的创建者 X101 已生效;
- T1 的结束者 X102 对 S_old 属于未来或活跃事务;
- 所以 T1 可见;
- T2 的创建者 X102 尚未对 S_old 生效,所以 T2 不可见。
结果:读到 10000。
观察者 S_mid:X102 提交后、X103 DELETE 前取得 Snapshot
- T1 的结束者 X102 已在 Snapshot 前提交,所以 T1 不可见;
- T2 的创建者 X102 已在 Snapshot 前提交;
- T2 尚未被有效结束,所以 T2 可见。
结果:读到 9000。
观察者 S_new:X103 DELETE 提交后取得 Snapshot
- T1 已被 X102 有效结束;
- T2 已被 X103 有效结束;
- 没有可见版本。
结果:返回 0 行。
这三个结果来自同一条更新链,不需要三份逻辑数据。
7.4 当前事务为什么能看到自己的写入:Command ID
同一事务的多条命令共享同一个 XID。仅靠 XID,无法区分“这是我之前插入的”还是“这是当前扫描开始后才产生的”。因此还需要 CID:
BEGIN;
INSERT INTO account_balance(account_id, balance_cents, note)
VALUES (2, 5000, 'created'); -- CID 0
SELECT *
FROM account_balance
WHERE account_id = 2; -- 后续命令,可见
DELETE FROM account_balance
WHERE account_id = 2; -- 更后命令
SELECT *
FROM account_balance
WHERE account_id = 2; -- 不可见
ROLLBACK;
Tuple 头中的 t_cid 会按状态解释为 cmin 或 cmax;复杂情况下通过 Combo CID 表达创建和删除命令关系。命令边界推进 curcid,从而同时满足:
- 后续命令能读到自己先前的写入;
- 一个已经开始的扫描不会看到扫描过程中后来才产生的版本。
7.5 Read Committed:Snapshot 的寿命是一条语句
BEGIN READ COMMITTED
Statement 1 -> Snapshot S1
Statement 2 -> Snapshot S2
Statement 3 -> Snapshot S3
COMMIT
普通 SELECT 看到:
- 该语句开始前已经提交的数据;
- 当前事务自己在此前命令中的写入;
- 看不到其他事务尚未提交的数据;
- 看不到其他事务在本语句执行期间才提交的数据。
因此两次相同 SELECT 之间若有其他事务提交,第二次可以返回不同结果。
写语句还有一个重要细节:UPDATE/DELETE 先按语句 Snapshot 找目标行;若该行已被并发事务修改,会等待对方结束。对方提交后,Read Committed 可以在最新版本上重新检查 WHERE 条件,再决定是否修改。这解释了为什么 RC 写语句不能简单理解为“整个语句永远只处理开始瞬间那张静止表面”。
7.6 Repeatable Read:Snapshot 的寿命是一整个事务
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- BEGIN 本身通常尚未固定 MVCC Snapshot
SELECT ...; -- 第一个非事务控制语句取得 S1
SELECT ...; -- 继续使用 S1
COMMIT;
在同一事务内:
- 其他事务在 S1 之后提交的版本仍不可见;
- 当前事务自己的后续写入仍可见;
- 若本事务试图修改一个在 S1 之后已被别的事务提交修改的版本,可能收到
40001,需要重试完整事务。
RR 提供稳定事务视图,但不等于完全可串行化。Write Skew 和 SSI 属于下一章。
7.7 MVCC 与锁的边界
MVCC 解决的是“读哪个版本”,锁解决的是“谁能同时修改或占有某个对象”。
| 场景 | 主要机制 | 是否可能等待 |
|---|---|---|
普通 SELECT 读取被并发 UPDATE 的行 | MVCC 读取旧版本 | 通常不等待写者 |
| 两个事务 UPDATE 同一逻辑行 | 行锁 + 事务结束等待 | 会等待或失败 |
SELECT ... FOR UPDATE | Snapshot 找行后主动获取行锁 | 可能等待 |
| DDL 与 DML | 表级锁 | 可能等待 |
| Serializable 读写依赖 | Snapshot + SSI 谓词锁监测 | 通常不因 SIReadLock 阻塞,但可能提交失败 |
主线二小结:Snapshot 决定“哪个版本属于我的历史视图”。隔离级别的核心差异,就是这个 Snapshot 持续一条语句还是持续整个事务。
8. 主线三:旧版本何时能被回收
8.1 “对最新查询不可见”不等于“可以删除”
一个旧版本可能经历以下状态:
新插入但未提交
-> live
-> recently dead
-> dead / removable
-> Line Pointer 可复用
recently dead 的含义是:它对新的 Snapshot 已不可见,但仍可能被某个旧 Snapshot 读取。因此 VACUUM 不能只看“现在最新值是什么”,而必须看整个系统还有没有观察者需要旧版本。
8.2 backend_xmin 是 Snapshot 对清理地平线的承诺
一个 backend 建立 Snapshot 后,会对外发布其清理需求。pg_stat_activity.backend_xmin 可以帮助判断它是否让全局可回收边界停在较老位置:
SELECT pid,
application_name,
state,
xact_start,
now() - xact_start AS xact_age,
backend_xid,
age(backend_xid) AS backend_xid_age,
backend_xmin,
age(backend_xmin) AS backend_xmin_age,
wait_event_type,
wait_event,
left(query, 200) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
因果链是:
旧 Snapshot 仍可能看到 T_old
-> 清理地平线不能越过 T_old
-> VACUUM 不能移除 T_old
-> Heap/Index 继续保留旧版本
需要注意:最老的 xact_start 不一定就是最老的 backend_xmin 持有者。一个刚 BEGIN 但尚未建立 Snapshot 的事务,与一个已经执行大查询的长事务影响可能不同。
8.3 长事务与 idle in transaction
长事务的风险有两类,必须分开检查:
- 长 Snapshot:阻止 VACUUM 回收,形成慢性膨胀和读放大。
- 长锁持有:让写者排队,形成显性阻塞和超时。
idle in transaction 尤其危险,因为客户端当前没有执行 SQL,但事务仍开放,可能继续持有:
- 行锁、表锁或 advisory lock;
- Snapshot 和
backend_xmin; - 未提交写入;
- pgxpool 中的一条连接槽位。
保护参数:
statement_timeout
只限制单条语句
idle_in_transaction_session_timeout
限制事务中空闲时间
[PG17+] transaction_timeout
限制整个事务持续时间
这些参数应按角色和工作负载设置。迁移、备份和合法长报表不能与普通 OLTP 角色共用一刀切阈值。
8.4 子事务、Savepoint 与 Aborted Subtransaction
Savepoint 的本质是子事务:
BEGIN;
INSERT INTO orders(id, state) VALUES (1, 'created');
SAVEPOINT sp_payment;
INSERT INTO payments(order_id, state) VALUES (1, 'charged');
ROLLBACK TO SAVEPOINT sp_payment;
UPDATE orders
SET state = 'payment_failed'
WHERE id = 1;
COMMIT;
主线仍然不变:
- 子事务写入获得 subxid;
ROLLBACK TO SAVEPOINT使该子事务及其子级 aborted;- 这些版本可能仍物理存在,但在可见性判断中无效;
RELEASE SAVEPOINT只是把子事务并入父事务,并非独立持久提交;- 顶层事务最终回滚时,已 release 的子事务也回滚。
PostgreSQL 会在 backend 进程结构中快速缓存有限数量的活跃 subxid。超过约 64 个活跃子事务后,其他 backend 判断这些 subxid 的归属时更可能访问 pg_subtrans,增加共享状态访问和 I/O。Savepoint 适合少量局部恢复,不适合百万行循环中“每行一个保存点”。
8.5 Snapshot Export:让多个观察者共享同一个历史视图
-- 协调者
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT pg_export_snapshot();
-- 保持事务开放
-- Worker;必须在第一条普通查询/写入前导入
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET TRANSACTION SNAPSHOT '00000003-0000001B-1';
SELECT ...;
COMMIT;
它不是新的可见性规则,只是让多个事务使用同一个既有 Snapshot:
- 导入者必须使用 Repeatable Read 或 Serializable;
- 导出者必须保持开放;
- 导出者结束后,Snapshot 标识不能再被新事务导入;
- 各 worker 自己未提交的写入仍不会互相可见;
- 导出者长期开放,同样会钉住清理地平线。
适合受控的并行一致性导出,不适合作为跨小时在线报表的默认协调方案。
8.6 这条主线落在哪些内部结构中
内部结构不需要孤立背诵,可以按四个问题映射:
| 内部结构 | 关键内容 | 回答的问题 |
|---|---|---|
HeapTupleHeader | t_xmin、t_xmax、t_cid、t_ctid、t_infomask | 谁创建、谁结束、命令顺序、更新链和锁/Hint 状态 |
SnapshotData | xmin、xmax、xip、subxid 信息、curcid、recovery 标记 | 观察者处于哪个历史视角 |
| ProcArray | backend 发布 XID、xmin 和子事务信息;Snapshot 获取者收集活跃事务 | 当前还有哪些事务可能影响可见性和清理地平线 |
pg_xact | 顶层事务 committed/aborted 等状态 | 创建或结束动作是否最终有效 |
pg_subtrans | subxid 到父事务的映射 | 子事务版本最终属于哪个顶层事务 |
| Buffer 中的 Heap Page | Executor 实际执行 Tuple 可见性检查的位置 | 查询为找到可见版本付出多少 CPU 和 Page 访问 |
| Visibility Map | Page 是否 all-visible/all-frozen | 能否支持 Index-Only Scan、VACUUM/Freeze 优化;它不是逐 Tuple 事务状态表 |
| Free Space Map | Page 可复用空间信息 | 清理后空间如何被后续写入重新利用 |
| WAL | 记录 DML、提交和清理等变化 | 崩溃恢复和副本如何重建同一版本历史 |
状态流可以概括为:
事务:VXID-only -> assigned XID -> in progress -> committed/aborted
子事务:active -> subcommitted/aborted -> 随顶层事务得到最终结果
Tuple:uncommitted -> live -> recently dead -> removable -> space reusable
主线三小结:VACUUM 不是“不够积极”,而是在遵守所有观察者的历史视图。要解决膨胀,先让不必要的旧 Snapshot 结束,再讨论清理参数。
9. 主线四:同一模型如何推导高性能、高并发和高可用
9.1 高性能维度:性能成本来自“保留多少不可见版本、保留多久”
9.1.1 完整放大链
Snapshot 持续更久
-> recently-dead Tuple 保留更多
-> Heap 与 Index 物理尺寸增大
-> 每次查询读取更多 Page
-> shared_buffers 与 OS Page Cache 有效容量下降
-> CPU 可见性检查、随机/顺序 I/O 增加
-> VACUUM、WAL、Checkpoint 压力增加
-> P95/P99 先于平均延迟恶化
这条链解释了为什么“只读长事务”也能让写库变慢:它自己不制造版本,却延长了别人制造的旧版本的寿命。
9.1.2 CPU
每个候选 Heap Tuple 可能需要:
- 检查 Hint Bits;
- 比较
xmin/xmax与 Snapshot 边界; - 判断 XID 是否在活跃集合中;
- 处理当前事务和 CID;
- 必要时查询
pg_xact或pg_subtrans。
单个 Tuple 的成本很小,但低选择性扫描、大量回表和大量死版本会把它放大。优化的第一目标通常不是“让一次可见性判断更快”,而是减少需要判断的候选版本数量。
9.1.3 Buffer 与 I/O
- Heap 膨胀会让同样的活跃业务数据占用更多 Page,挤出真正有价值的缓存页。
- Index Scan 可能命中大量已失效 Index Tuple,再随机回表判断可见性。
- Seq Scan 会顺序读取更多膨胀 Page,即使存储顺序带宽足够,也会增加 CPU 和缓存污染。
- 不能只看 Buffer Cache 命中率;系统可能“高命中地读取大量无效 Page”。还应看 Buffers/call、rows/call、relation size 和 dead tuple 趋势。
9.1.4 HOT、索引、WAL 与 Checkpoint
- UPDATE 总会产生新的 Heap 版本。
- 若不能 HOT,还要为相关索引创建新 Index Tuple,增加 WAL 和索引膨胀。
- DELETE 后的 Index Tuple 通常也要等待 VACUUM 清理。
- 膨胀使后续 DML 触碰更多 Page,增加 WAL、脏页和 Checkpoint 写回。
9.1.5 网络往返与事务时长
BEGIN
SELECT
应用计算
UPDATE
外部 HTTP
INSERT
COMMIT
每增加一次数据库往返或外部等待,都延长连接、锁和 Snapshot 的持有时间。高性能事务设计通常遵循:
- 事务前完成输入校验、外部读取和慢计算;
- 事务内只保留必须原子提交的 SQL;
- 能用一条原子 SQL 完成的,不拆成聊天式多轮交互;
- 可靠通知使用 Outbox,而不是在事务内等待消息系统。
9.1.6 Temporary File 与尾延迟
MVCC 不直接创建临时文件,但膨胀会间接放大排序、Hash Join 和 Hash Aggregate 的输入规模;统计失真还可能导致错误计划和 spill。应联合观察:
temp_bytes/temp_files;EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS);- 实际/估算行数;
- relation size 与 dead tuple;
- P50/P95/P99,而不只看平均值。
9.1.7 [PG18] AIO 的正确位置
PostgreSQL 18 的异步 I/O 可以改善部分扫描和维护路径的 I/O 提交与等待方式,但它不改变:
- Snapshot 的含义;
xmin/xmax可见性规则;- VACUUM 不能删除旧 Snapshot 仍需要的版本这一安全条件。
若根因是长事务导致膨胀,AIO 只能改善部分症状,不能替代事务边界治理。
9.2 高并发维度:MVCC 降低读写冲突,但不消除写写竞争
9.2.1 普通读写并行
普通读者可以读取旧版本,因此通常不会等待正在修改同一行的写者;写者也不必等待普通读者释放行级读锁。这是 MVCC 提升多用户吞吐的核心来源。
9.2.2 写写冲突仍要序列化
两个事务修改同一逻辑行时,不能同时产生两个无约束的最终版本。它们仍要通过行锁和事务结束等待协调:
- RC 等待后可在新版本上重检条件;
- RR/Serializable 可能因为视图已过期而报
40001; - 死锁可能报
40P01。
发生 40001 或 40P01 后,应重试完整事务,而不是只重试最后一条 SQL,因为先前读取、Snapshot 和锁顺序已经失效。
9.2.3 热点行与热点索引页
单行计数器、单库存行、单队列头等热点不会因 MVCC 自动消失:
大量写者
-> 同一行锁队列
-> 连接被占用
-> 超时与重试
-> 更多锁请求和 WAL
-> 重试风暴
可选方案包括:原子条件 UPDATE、分片计数、批量聚合、SKIP LOCKED 工作队列和事件流。具体锁设计在下一章展开。
9.2.4 连接池与 Backpressure
以下五个量不能混为一谈:
| 指标 | 含义 |
|---|---|
| goroutine 数 | 应用内部并发任务数量 |
| 连接数 | 已建立数据库会话数量 |
| 活跃查询数 | 当前执行或等待的 SQL 数量 |
| TPS | 每秒完成的事务数量 |
| Pool 排队数 | 等待连接的请求数量 |
推荐的并发控制链:
入口限流
-> 业务级 semaphore/worker 上限
-> pgxpool MaxConns 与 Acquire context
-> PostgreSQL 可承受的活跃查询数
-> statement/transaction timeout
Pool 满并不自动意味着应该增加连接数。根因可能是长事务、锁等待、慢 I/O 或事务内外部调用。应同时观察 Pool Acquire 延迟、数据库活跃查询、Wait Event 和事务年龄。
9.2.5 幂等与重试
高并发系统中的重试必须满足:
- 只对可重试错误分类处理;
- 重试完整事务;
- 使用退避和抖动,避免所有请求同时重试;
- 业务副作用由幂等键和唯一约束保护;
- 不把提交网络错误直接当作“安全重试”。
9.3 高可用维度:Snapshot、提交持久性和副本回放是三个不同问题
9.3.1 当前节点可见不等于跨节点持久
COMMIT 后,新 Snapshot 在 Primary 上可以看到事务,不代表:
- 异步 Standby 已接收 WAL;
- Standby 已回放到该提交;
- WAL 归档和备份已覆盖该事务。
RPO 取决于提交确认策略、复制模式和故障位置,而不是 Snapshot 本身。
9.3.2 Replica Snapshot 与 Recovery Conflict
Standby 一边回放 WAL,一边为只读查询维护恢复 Snapshot。Primary 上 VACUUM 清理旧版本的 WAL 回放到 Standby 时,若长查询仍需要这些旧版本,就出现冲突:
Standby 长查询需要旧版本
vs.
WAL 回放需要移除或重用该版本
系统只能在三种代价之间选择:
- 等待一段时间后取消查询;
- 容忍更多回放延迟;
- 开启
hot_standby_feedback,把 xmin 需求反馈给 Primary。
hot_standby_feedback=on 会减少 Snapshot Conflict,但可能让 Primary 延迟清理并产生膨胀。它不是免费开关,而是在“副本查询可用性”和“主库空间/性能”之间转移成本。
常用观察 SQL:
-- Standby
SELECT pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_last_xact_replay_timestamp();
SELECT datname,
confl_snapshot,
confl_lock,
confl_bufferpin,
confl_deadlock
FROM pg_stat_database_conflicts;
-- Primary
SELECT application_name,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
backend_xmin
FROM pg_stat_replication;
9.3.3 Failover 与 Commit 结果不确定
客户端发送 COMMIT 后连接中断,可能发生:
- COMMIT 尚未到达服务器;
- 服务器已接收但事务回滚;
- 旧 Primary 已提交,但异步副本尚未包含该事务;
- 事务已复制并存在于新 Primary,只是客户端没收到成功响应。
因此网络 EOF、超时或连接重置不能简单解释为“肯定未提交”。正确处理依赖:
- 全局业务请求 ID;
- 数据库唯一约束;
- 可查询的业务结果;
- Outbox/账本/对账流程;
- 复制模式和 Failover 时间线。
pg_xact_status() 只能辅助判断近期、同一集群中的某个 xid8,不能充当跨集群、跨长期的业务账本。
9.3.4 逻辑复制与 XID
逻辑复制按源端事务提交边界传输变更,但订阅端有自己的 XID、Tuple 和 Snapshot:
- 源端物理
xmin/xmax不应被当成业务版本复制语义; - 订阅端查询可见性由订阅端事务状态决定;
- 复制延迟意味着同一业务对象在源、目标的可见时刻不同。
9.3.5 RTO、膨胀与 Fencing
长事务和膨胀还会间接增加 RTO:
- 大事务失败后会遗留更多无效版本,需要后续 VACUUM 清理;
- 数据量、备份量和恢复量增加;
- WAL 增加使 Standby 追赶更慢。
MVCC 只在一条事务历史中定义版本顺序。若两个 Primary 同时可写,会形成两条无法靠 xmin/xmax 自动合并的历史。因此高可用架构必须有可靠 Fencing 和唯一写入口。
9.4 三维影响矩阵
| 维度 | 核心收益 | 核心风险 | 最重要的观测量 |
|---|---|---|---|
| 高性能 | 读者可读旧版本,减少读写阻塞 | 长 Snapshot、死版本、缓存/I/O/WAL/空间放大 | relation size、dead tuples、Buffers/call、VACUUM、P95/P99 |
| 高并发 | 普通读写并行 | 热点行、长锁、subxid 溢出、连接排队、重试风暴 | 活跃查询、锁等待、backend_xmin、xact age、Pool Acquire |
| 高可用 | 清晰定义节点内可见性和副本读取边界 | 异步丢失、Recovery Conflict、feedback 膨胀、未知提交 | replay lag、confl_snapshot、复制 backend_xmin、RPO/RTO |
主线四小结:高性能、高并发和高可用并不是三套独立知识。它们分别在问:版本保留成本、同时访问同一版本链的协调成本,以及这条版本历史跨节点传播时的成本。
10. 场景选型:先决定 Snapshot 需要活多久
面对事务设计问题,不要先问“用 RC 还是 RR”,而应按以下顺序判断:
- 业务是否能用一条原子 SQL 表达?
- 多条语句之间是否必须看到同一个历史视图?
- 是否会修改同一热点行,或者依赖跨行不变量?
- Snapshot 最长需要保持多久?
- 失败后能否完整重试,副作用是否幂等?
- 该事务是否会运行在 Primary、HA Standby 还是专用报表节点?
| 业务场景 | 推荐方案 | 不推荐方案 | 选择依据 | 三维代价 |
|---|---|---|---|---|
| 普通短 OLTP | Read Committed;短事务;原子 SQL;必要时行锁 | 为了“结果稳定”把所有请求改为 RR | Snapshot 只需覆盖单条语句,清理地平线推进快 | 性能和并发代价低;业务需处理跨语句变化 |
| 多步计算必须使用稳定视图 | Repeatable Read,并准备处理 40001 | 在 RC 中假设两次读取不变 | Snapshot 需要覆盖整个事务 | 旧版本保留更久;并发更新可能失败 |
| 跨行强业务不变量 | Serializable 或明确锁协议 | 仅依赖 RR Snapshot | 稳定视图不等于可串行化 | 有监控和重试成本;详见下一章 |
| 长时间一致性报表 | 专用报表副本/数仓;必要时只读安全 Snapshot | 在 Primary 开放数小时 RR | 与 OLTP 隔离,并明确查询取消、延迟、膨胀取舍 | 需要额外节点与复制治理 |
| 并行一致性导出 | 一个导出者 + 多个导入相同 Snapshot 的 RR worker;严格限时 | 多个 worker 各自同时 BEGIN | 所有 worker 必须共享同一观察点 | 导出者钉住 xmin;故障时整批重建 |
| 批量任务允许局部失败 | 分批提交;少量 Savepoint;staging + 集合式 SQL | 每行一个 Savepoint | 控制 subxid 数量与顶层事务大小 | 降低 pg_subtrans、回滚和恢复压力 |
| 外部支付/HTTP | 本地短事务 + 幂等状态机/Outbox/Saga | 持有数据库事务等待外部服务 | 外部系统不属于 PostgreSQL 原子提交域 | 降低锁与 Snapshot 时长;增加显式最终一致性设计 |
| Standby 长查询 | 专用报表副本;查询预算;按容量决定 feedback | 全局无限制开启 hot_standby_feedback | 在查询取消、replay lag、Primary 膨胀之间显式取舍 | 运维复杂度高,但故障域更清晰 |
| 乐观并发控制/审计 | 显式 version bigint、事件 ID、业务请求 ID | 使用系统 xmin 作为永久版本 | 业务版本必须跨回绕、恢复和迁移保持语义 | 多一列和约束,换取可维护性 |
11. 三个实验:按主线逐段验证
所有实验建议使用独立 psql 终端,并显式标记 Session A/B/C。示例 XID、ctid 和 Page 位置会因环境不同而变化,不能把示例数值写进自动断言。
11.1 实验一:验证“版本已提交”与“旧 Snapshot 可见”不是一回事
11.1.1 要验证的链路
A 创建版本但未提交
-> B 的旧 Snapshot 看不到
A 后来提交
-> B 的旧 Snapshot 仍看不到
-> 新 Snapshot 才能看到
11.1.2 准备
DROP TABLE IF EXISTS mvcc_insert_demo;
CREATE TABLE mvcc_insert_demo (
id bigint PRIMARY KEY,
note text NOT NULL
);
11.1.3 时间线
T0 B: 开启 RR,并执行查询固定旧 Snapshot
T1 A: INSERT,记录 XID,但不提交
T2 B: 查询 0 行;A 状态为 in progress
T3 A: COMMIT
T4 B: A 状态为 committed,但仍查询 0 行
T5 C: 新 Snapshot 查询到该行
Session B:先固定旧 Snapshot
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot() AS b_snapshot,
count(*) AS visible_rows
FROM mvcc_insert_demo
WHERE id = 1;
-- visible_rows = 0
Session A:插入但不提交
BEGIN;
INSERT INTO mvcc_insert_demo(id, note)
VALUES (1, 'created by A');
SELECT pg_current_xact_id() AS a_xid8,
ctid,
xmin,
xmax,
cmin,
id,
note
FROM mvcc_insert_demo
WHERE id = 1;
-- 记录 a_xid8;A 能看到自己的版本
Session B:提交前观察
把 <A_XID8> 替换为 Session A 返回值:
SELECT pg_xact_status('<A_XID8>'::xid8) AS a_status;
-- in progress
SELECT ctid, xmin, xmax, id, note
FROM mvcc_insert_demo
WHERE id = 1;
-- 0 rows
B 的普通 SELECT 不会为了等待 A 提交而阻塞,它直接忽略不可见版本。B 也无法通过普通 SQL 读取不可见行的 xmin;该 xmin 由 A 自己或物理诊断工具观察。
Session A:提交
COMMIT;
Session B:现实状态已变,历史视图不变
SELECT pg_xact_status('<A_XID8>'::xid8) AS a_status;
-- committed
SELECT pg_current_snapshot() AS b_snapshot,
count(*) AS visible_rows
FROM mvcc_insert_demo
WHERE id = 1;
-- 仍为 0
COMMIT;
Session C:新 Snapshot
SELECT pg_current_snapshot() AS c_snapshot,
ctid,
xmin,
xmax,
id,
note
FROM mvcc_insert_demo
WHERE id = 1;
-- 返回 1 行
诊断与解释
SELECT pid,
state,
xact_start,
backend_xid,
backend_xmin,
wait_event_type,
wait_event,
left(query, 120) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;
- A 写入后通常发布
backend_xid; - B 固定 RR Snapshot 后通常发布
backend_xmin; - A 的事务状态从 in progress 变成 committed;
- B 的 Snapshot 仍把 A 视为观察点之后的事务,因此结果不变。
清理
DROP TABLE mvcc_insert_demo;
11.2 实验二:验证隔离级别的差异来自 Snapshot 生命周期
11.2.1 要验证的链路
Read Committed: 每条语句重新观察
Repeatable Read: 整个事务复用同一观察点
11.2.2 准备
DROP TABLE IF EXISTS rc_snapshot_demo;
CREATE TABLE rc_snapshot_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
state text NOT NULL
);
INSERT INTO rc_snapshot_demo(state) VALUES ('open');
Read Committed 对照
Session A:
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT pg_current_snapshot() AS snap,
(SELECT count(*)
FROM rc_snapshot_demo
WHERE state = 'open') AS open_count;
-- open_count = 1
Session B:
BEGIN;
INSERT INTO rc_snapshot_demo(state) VALUES ('open');
COMMIT;
Session A 再执行同一条语句:
SELECT pg_current_snapshot() AS snap,
(SELECT count(*)
FROM rc_snapshot_demo
WHERE state = 'open') AS open_count;
-- open_count = 2;Snapshot 通常不同
COMMIT;
把 Snapshot 和计数放在同一 SQL 中,是为了确保二者属于同一语句观察点。
Repeatable Read 对照
先把表恢复为一行:
TRUNCATE rc_snapshot_demo RESTART IDENTITY;
INSERT INTO rc_snapshot_demo(state) VALUES ('open');
Session A:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot() AS snap,
count(*) AS open_count
FROM rc_snapshot_demo
WHERE state = 'open';
-- 1
Session B 再插入并提交:
INSERT INTO rc_snapshot_demo(state) VALUES ('open');
Session A:
SELECT pg_current_snapshot() AS snap,
count(*) AS open_count
FROM rc_snapshot_demo
WHERE state = 'open';
-- 仍为 1,Snapshot 保持同一事务视图
COMMIT;
诊断与解释
SELECT pid,
state,
xact_start,
query_start,
backend_xid,
backend_xmin,
wait_event_type,
wait_event
FROM pg_stat_activity
WHERE datname = current_database();
- RC 两条语句之间可以释放旧 Snapshot,再为下一语句建立新 Snapshot;
- RR 在第一个非事务控制语句后持续持有事务 Snapshot;
- 两组普通 SELECT 都不需要阻塞 Session B 的 INSERT。
清理
DROP TABLE rc_snapshot_demo;
11.3 实验三:验证 UPDATE/DELETE 的物理版本链和 VACUUM 安全边界
11.3.1 要验证的链路
UPDATE 创建新版本并结束旧版本
DELETE 结束当前版本
旧 Snapshot 仍能读旧版本
VACUUM 必须等旧 Snapshot 离开
11.3.2 准备
仅在实验数据库执行:
CREATE EXTENSION IF NOT EXISTS pageinspect;
DROP TABLE IF EXISTS tuple_version_demo;
CREATE TABLE tuple_version_demo (
id integer PRIMARY KEY,
note text NOT NULL
) WITH (autovacuum_enabled = false);
INSERT INTO tuple_version_demo(id, note)
VALUES (1, 'v1');
SELECT ctid, xmin, xmax, cmin, cmax, *
FROM tuple_version_demo;
本实验临时关闭单表 autovacuum,只是为了减少演示期间的自动清理干扰。生产表不得照搬。
物理 Page 观察 SQL
小表通常从 block 0 开始:
SELECT lp,
lp_flags,
lp_off,
lp_len,
t_xmin,
t_xmax,
t_field3 AS t_cid_or_xvac,
t_ctid,
raw_flags,
combined_flags
FROM heap_page_items(get_raw_page('tuple_version_demo', 0)) AS h
LEFT JOIN LATERAL heap_tuple_infomask_flags(
h.t_infomask,
h.t_infomask2
) AS f ON true
ORDER BY lp;
Session A:建立旧 Snapshot
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot() AS a_snapshot,
ctid,
xmin,
xmax,
id,
note
FROM tuple_version_demo
WHERE id = 1;
-- v1;保持事务开放
Session B:UPDATE 并提交
BEGIN;
UPDATE tuple_version_demo
SET note = 'v2'
WHERE id = 1
RETURNING ctid, xmin, xmax, id, note;
COMMIT;
Session C 执行物理 Page 观察 SQL。通常可看到:
- 旧版本
t_xmax是 UPDATE XID; - 旧版本
t_ctid指向新版本; - 新版本
t_xmin是 UPDATE XID; - 若满足条件,flags 可能显示 HOT 更新,但不要硬编码断言。
此时:
-- Session A:旧 Snapshot
SELECT ctid, xmin, xmax, id, note
FROM tuple_version_demo
WHERE id = 1;
-- 仍为 v1
-- Session C:新 Snapshot
SELECT ctid, xmin, xmax, id, note
FROM tuple_version_demo
WHERE id = 1;
-- v2
Session B:DELETE 并提交
BEGIN;
DELETE FROM tuple_version_demo
WHERE id = 1
RETURNING ctid, xmin, xmax, id, note;
COMMIT;
新 Snapshot:
SELECT *
FROM tuple_version_demo
WHERE id = 1;
-- 0 rows
Session A 的旧 Snapshot:
SELECT ctid, xmin, xmax, id, note
FROM tuple_version_demo
WHERE id = 1;
-- 仍可看到 v1
再次执行 Page 观察 SQL,物理 Page 上仍可看到旧版本;当前 v2 版本的 t_xmax 通常是 DELETE XID。heap_page_items() 展示物理内容,不按当前 SQL Snapshot 过滤。
VACUUM 与旧 Snapshot
Session C:
VACUUM (VERBOSE, ANALYZE) tuple_version_demo;
因为 Session A 仍需要旧版本,VACUUM 不能违反可见性安全边界强行移除它。
SELECT relname,
n_live_tup,
n_dead_tup,
last_vacuum,
vacuum_count
FROM pg_stat_user_tables
WHERE relname = 'tuple_version_demo';
结束 Session A:
COMMIT;
Session C 再执行:
VACUUM (VERBOSE, ANALYZE) tuple_version_demo;
随后再次观察 Page。Line Pointer 可能变为 unused、dead、redirect,或版本已被移除;具体形态受 HOT、Page Pruning 和执行时机影响,不应断言固定 LP 数量。
诊断与清理
SELECT pid,
state,
now() - xact_start AS xact_age,
backend_xid,
backend_xmin,
wait_event_type,
wait_event,
left(query, 150) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
SELECT pg_relation_size('tuple_version_demo') AS heap_bytes,
pg_indexes_size('tuple_version_demo') AS index_bytes;
DROP TABLE tuple_version_demo;
安全边界:
pageinspect只用于受控诊断,不进入业务查询路径;- 生产中不要关闭 autovacuum 来“保留证据”;
- 并发 DML、Pruning 和 VACUUM 会改变 Page,实验必须记录明确时间线。
12. Go 与 pgx:把版本生命周期落实为代码边界
应用代码不需要手工判断 Tuple 可见性,但必须正确控制 Snapshot 的起点和终点:
BeginTx
-> 选择隔离级别和访问模式
-> 执行最小原子 SQL 集
-> 任意错误/ Panic 路径 Rollback
-> Commit 成为业务可见性与持久性边界
-> 连接归还 Pool
12.1 设计原则
- Service/Use Case 决定业务事务边界,Repository 只执行 SQL。
- Repository 接收
DBTX或pgx.Tx,不能私自把一个业务事务切成多个独立提交。 - 使用
BeginTx明确Isolation、AccessMode和Deferrable。 defer Rollback覆盖普通错误、提前返回和 Panic 路径;不要吞掉 Panic。- 必须单独检查
Commit错误,不能因为返回 error 就断言“肯定未提交”。 - pgx 的 Begin context 只约束 BEGIN/连接获取本身;后续 context 取消不会自动替应用回滚事务。
- 事务中只放必须原子提交的数据库操作;慢 HTTP/RPC、消息等待和用户交互移到事务外。
- 可能发生未知提交结果的业务必须有幂等键、唯一约束和可查询结果。
- 锁定多个对象时使用稳定顺序,降低死锁概率。
40001/40P01应由上层重试完整事务,并使用退避和抖动。
12.2 配套 Schema
CREATE TABLE accounts (
account_id bigint PRIMARY KEY,
balance_cents bigint NOT NULL CHECK (balance_cents >= 0)
);
CREATE TABLE transfers (
request_id text PRIMARY KEY,
from_id bigint NOT NULL REFERENCES accounts(account_id),
to_id bigint NOT NULL REFERENCES accounts(account_id),
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
request_id 是业务幂等锚点,不是数据库 XID。即使连接在 COMMIT 期间中断,调用方仍可按它查询事务最终效果。
12.3 可编译事务模板
package transaction
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrAlreadyProcessed = errors.New("request already processed")
// DBTX 让 Repository 同时接受连接池和显式事务。
// Repository 只执行 SQL,不决定业务事务边界。
type DBTX interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
Query(context.Context, string, ...any) (pgx.Rows, error)
QueryRow(context.Context, string, ...any) pgx.Row
}
type AccountRepository struct{}
func (AccountRepository) LockBalance(
ctx context.Context,
q DBTX,
accountID int64,
) (int64, error) {
const sql = `
SELECT balance_cents
FROM accounts
WHERE account_id = $1
FOR UPDATE`
var balance int64
if err := q.QueryRow(ctx, sql, accountID).Scan(&balance); err != nil {
return 0, fmt.Errorf("lock account %d: %w", accountID, err)
}
return balance, nil
}
func (AccountRepository) AddBalance(
ctx context.Context,
q DBTX,
accountID int64,
delta int64,
) error {
const sql = `
UPDATE accounts
SET balance_cents = balance_cents + $2
WHERE account_id = $1`
tag, err := q.Exec(ctx, sql, accountID, delta)
if err != nil {
return fmt.Errorf("update account %d: %w", accountID, err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf(
"account %d: expected one row, got %d",
accountID,
tag.RowsAffected(),
)
}
return nil
}
func (AccountRepository) InsertTransfer(
ctx context.Context,
q DBTX,
requestID string,
fromID int64,
toID int64,
amount int64,
) (bool, error) {
const sql = `
INSERT INTO transfers(request_id, from_id, to_id, amount_cents)
VALUES ($1, $2, $3, $4)
ON CONFLICT (request_id) DO NOTHING`
tag, err := q.Exec(ctx, sql, requestID, fromID, toID, amount)
if err != nil {
return false, fmt.Errorf("insert transfer: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// RunTx 统一控制事务生命周期。
// cleanupTimeout 应由服务关闭预算和 SLO 注入,而不是机械照抄。
func RunTx(
ctx context.Context,
pool *pgxpool.Pool,
opts pgx.TxOptions,
cleanupTimeout time.Duration,
fn func(context.Context, pgx.Tx) error,
) (err error) {
tx, err := pool.BeginTx(ctx, opts)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// Commit 成功后 Rollback 会返回 pgx.ErrTxClosed,可安全忽略。
// 若 fn 发生 panic,defer 仍执行,panic 随后继续向上传播。
defer func() {
rollbackCtx, cancel := context.WithTimeout(
context.Background(),
cleanupTimeout,
)
defer cancel()
rollbackErr := tx.Rollback(rollbackCtx)
if rollbackErr == nil || errors.Is(rollbackErr, pgx.ErrTxClosed) {
return
}
// 不覆盖更重要的业务错误或 Commit 错误。
// 生产代码还应记录 rollbackErr、连接状态和 request_id。
if err == nil {
err = fmt.Errorf("rollback transaction: %w", rollbackErr)
}
}()
if err = fn(ctx, tx); err != nil {
return err
}
if err = tx.Commit(ctx); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
type Service struct {
pool *pgxpool.Pool
repo AccountRepository
}
func NewService(pool *pgxpool.Pool) *Service {
return &Service{pool: pool}
}
func (s *Service) Transfer(
ctx context.Context,
requestID string,
fromID int64,
toID int64,
amount int64,
) error {
if requestID == "" || fromID == toID || amount <= 0 {
return errors.New("invalid transfer input")
}
// 鉴权、风控 RPC、参数标准化等慢操作应在进入事务前完成。
opts := pgx.TxOptions{
IsoLevel: pgx.ReadCommitted,
AccessMode: pgx.ReadWrite,
DeferrableMode: pgx.NotDeferrable,
}
return RunTx(
ctx,
s.pool,
opts,
3*time.Second,
func(ctx context.Context, tx pgx.Tx) error {
inserted, err := s.repo.InsertTransfer(
ctx,
tx,
requestID,
fromID,
toID,
amount,
)
if err != nil {
return err
}
if !inserted {
// 生产系统还应查询既有记录,并比较请求参数摘要。
return ErrAlreadyProcessed
}
// 以稳定顺序锁定账户,降低并发转账死锁概率。
firstID, secondID := fromID, toID
if firstID > secondID {
firstID, secondID = secondID, firstID
}
firstBalance, err := s.repo.LockBalance(ctx, tx, firstID)
if err != nil {
return err
}
secondBalance, err := s.repo.LockBalance(ctx, tx, secondID)
if err != nil {
return err
}
fromBalance := firstBalance
if fromID == secondID {
fromBalance = secondBalance
}
if fromBalance < amount {
return errors.New("insufficient balance")
}
if err := s.repo.AddBalance(ctx, tx, fromID, -amount); err != nil {
return err
}
if err := s.repo.AddBalance(ctx, tx, toID, amount); err != nil {
return err
}
return nil
},
)
}
func SQLState(err error) string {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code
}
return ""
}
12.4 BeginTx 选项如何对应 Snapshot 语义
| pgx 选项 | SQL 对应 | Snapshot/并发含义 |
|---|---|---|
pgx.ReadCommitted | READ COMMITTED | 每条语句新 Snapshot;默认 OLTP 选择 |
pgx.RepeatableRead | REPEATABLE READ | 事务级稳定 Snapshot;并发更新可能 40001 |
pgx.Serializable | SERIALIZABLE | RR 视图上增加 SSI 检测;完整重试见下一章 |
pgx.ReadOnly | READ ONLY | 拒绝多数持久对象写入;不代表数据库绝不产生任何内部写入 |
pgx.ReadWrite | READ WRITE | 普通写事务 |
pgx.Deferrable | DEFERRABLE | 只有 Serializable + Read Only 组合有实际意义;首个安全 Snapshot 可能等待 |
只读安全报表可以这样开始:
opts := pgx.TxOptions{
IsoLevel: pgx.Serializable,
AccessMode: pgx.ReadOnly,
DeferrableMode: pgx.Deferrable,
}
12.5 Commit 错误必须按“结果是否已知”分类
| 错误类别 | 结果判断 | 处理方式 |
|---|---|---|
| 服务器明确返回事务错误 | 通常可依据 SQLSTATE 判断已回滚/失败 | 分类记录;按语义决定是否重试 |
pgx.ErrTxCommitRollback | COMMIT 实际变成 ROLLBACK,结果已知未提交 | 可按完整事务重试策略处理 |
| 网络 EOF、超时、连接重置 | 事务可能已提交,也可能未提交 | 先按业务幂等键查询结果,不直接重复副作用 |
| 节点故障或 Failover | 还取决于事务是否复制到新 Primary | 查询业务账本并结合复制/RPO 时间线 |
12.6 为什么 Repository 不能自行开启独立事务
错误结构:
Service.Transfer
-> DebitRepository.Debit() 自己 BEGIN/COMMIT
-> CreditRepository.Credit() 自己 BEGIN/COMMIT
若第二步失败,第一步已经提交,业务原子性被切断。正确结构是:
Service.Transfer
-> BeginTx
-> DebitRepository.Debit(tx)
-> CreditRepository.Credit(tx)
-> TransferRepository.Insert(tx)
-> Commit
Repository 可以提供明确命名的独立原子操作,但不能在调用方不知情的情况下静默决定事务边界。
13. 生产排障 Runbook:沿“四问法”定位,而不是见到慢就杀 SQL
典型症状包括:数据“看不见”、锁等待、查询变慢、磁盘增长、autovacuum 清不掉、Pool Acquire 变慢、Standby 查询被取消。它们看似不同,排查时仍回到四个问题。
13.1 第一步:先给症状分类
| 症状 | 优先问题 |
|---|---|
| 刚提交的数据在某会话看不到 | Q2:观察者是否持有旧 RR/Serializable Snapshot |
| UPDATE/DELETE 一直等待 | Q3:是否有并发写者或锁持有者 |
| dead tuple 持续增长、VACUUM 反复运行 | Q4:谁在阻止清理地平线推进 |
| 查询 Buffers/call 和 P99 上升 | Q4 + 资源放大:旧版本是否造成读放大 |
| Pool Acquire 高但 CPU 不高 | 事务是否长时间占连接、等锁或等待外部服务 |
Standby confl_snapshot 增长 | Replica Snapshot 是否与 WAL 清理回放冲突 |
| COMMIT 返回连接错误 | 提交结果是否不确定,是否有业务幂等锚点 |
13.2 Q1:谁在产生版本
先确认哪些表有大量 INSERT/UPDATE/DELETE,以及事务从何时开始:
SELECT relid::regclass AS relation,
n_live_tup,
n_dead_tup,
n_tup_ins,
n_tup_upd,
n_tup_hot_upd,
n_tup_del,
last_autovacuum,
autovacuum_count,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
重点关联:
- UPDATE/DELETE 速率何时上升;
- HOT 比例是否下降;
- 是否有发布新增索引、更新索引列或扩大行宽;
- 是否出现大批处理或重试风暴;
- relation size 是否与写入速率同步异常增长。
n_dead_tup 是估算值,不能单独作为最终证据。需要结合 relation size 历史、VACUUM 日志、执行计划 Buffers 和必要时的 pgstattuple 采样。
13.3 Q2:谁拿着旧 Snapshot
SELECT pid,
datname,
usename,
application_name,
client_addr,
state,
xact_start,
state_change,
now() - xact_start AS xact_age,
now() - state_change AS state_age,
backend_xid,
age(backend_xid) AS backend_xid_age,
backend_xmin,
age(backend_xmin) AS backend_xmin_age,
wait_event_type,
wait_event,
left(query, 300) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY age(backend_xmin) DESC NULLS LAST,
xact_start;
判断时不要只看“运行最久”:
xact_age表示事务总年龄;state_age表示当前 active/idle 状态持续时间;backend_xid说明该事务是否获得顶层 XID;backend_xmin更直接说明它是否参与清理地平线;state='idle in transaction'表示 SQL 已停,但事务未结束;wait_event区分等锁、等 I/O、等客户端还是正在运行。
对于“为什么看不到已提交数据”,还要确认观察者的隔离级别和当前 Snapshot:
SHOW transaction_isolation;
SELECT pg_current_snapshot();
13.4 Q3:谁结束或锁定了版本
出现写等待时,查 blocker,而不是只看被阻塞 SQL:
WITH blocked AS (
SELECT pid,
pg_blocking_pids(pid) AS blockers,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0
)
SELECT b.pid AS blocked_pid,
b.wait_event_type,
b.wait_event,
left(b.query, 200) AS blocked_query,
blocker.pid AS blocker_pid,
blocker.application_name AS blocker_app,
blocker.state AS blocker_state,
now() - blocker.xact_start AS blocker_xact_age,
blocker.backend_xid,
blocker.backend_xmin,
left(blocker.query, 200) AS blocker_query
FROM blocked AS b
CROSS JOIN LATERAL unnest(b.blockers) AS p(pid)
JOIN pg_stat_activity AS blocker
ON blocker.pid = p.pid
ORDER BY blocker.xact_start;
真正 blocker 可能已经是 idle in transaction,其最后显示的 query 甚至可能只是一个普通 SELECT。要判断它此前是否执行过 DML、持有什么锁、由哪个应用请求创建。
13.5 Q4:谁阻止版本回收
除了普通 backend,还要检查复制反馈、复制槽和 Prepared Transaction。
本地 backend
使用前面的 backend_xmin 查询。
Standby feedback
SELECT application_name,
client_addr,
state,
sync_state,
backend_xmin,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn
FROM pg_stat_replication
ORDER BY age(backend_xmin) DESC NULLS LAST;
复制槽
SELECT slot_name,
slot_type,
active,
active_pid,
xmin,
age(xmin) AS xmin_age,
catalog_xmin,
age(catalog_xmin) AS catalog_xmin_age,
restart_lsn,
confirmed_flush_lsn
FROM pg_replication_slots;
Prepared Transaction
SELECT gid,
prepared,
owner,
database,
transaction,
age(transaction) AS transaction_age
FROM pg_prepared_xacts
ORDER BY prepared;
Prepared Transaction 可能长期持锁和事务状态;复制槽可能阻止 WAL 或 catalog 清理。它们与普通 backend_xmin 不同,但都属于“系统仍承诺保留历史”的来源。
13.6 把可见性问题映射到资源
| 观察结果 | 更可能的因果链 | 下一步 |
|---|---|---|
| CPU 高、Buffers/call 高、I/O 不高 | 内存命中大量旧版本,CPU 用于可见性/过滤 | 对比 relation size、dead tuples、Rows Removed、计划 |
| iowait 高、表和索引持续增长 | 膨胀导致读放大,VACUUM/Checkpoint 与前台竞争 | 找最老 horizon,关联 autovacuum 与写入速率 |
| CPU 低、Pool Acquire 高 | 长事务占连接、锁等待、外部 RPC 或连接泄漏 | 查 xact age、wait event、应用 span、未关闭 Rows/Batch |
transactionid/tuple 锁等待 | 写写冲突或等待对方事务结束 | 查 blocker、热点键和锁顺序 |
| WAL bytes 激增 | 非 HOT UPDATE、索引维护、重试或大批写 | 看 HOT 比例、索引列、Checkpoint 和失败重试量 |
| temp bytes 高 | 膨胀/统计错误放大中间结果 | EXPLAIN、统计信息、work_mem 与查询形态联合分析 |
Standby confl_snapshot 增长 | Recovery Snapshot 与清理回放冲突 | 查长查询、延迟预算、feedback 与 Primary bloat |
若启用了 pg_stat_statements,优先找最早出现以下变化的 queryid:
- mean/P95/P99 上升;
- shared/local/temp blocks per call 上升;
- rows per call 变化;
- WAL per call 上升。
然后对代表性参数执行:
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
SELECT ...;
从计划树底部开始区分:
- 估算错误导致错误计划;
- 计划没变,但 Heap/Index 膨胀导致相同计划读更多 Page;
- SQL 本身很快,延迟发生在锁或 Pool 排队;
- Standby 上延迟来自 WAL 回放和 Recovery Conflict。
13.7 高可用分支
Standby 上检查:
SELECT now() - pg_last_xact_replay_timestamp() AS replay_time_lag,
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn();
SELECT datname,
confl_tablespace,
confl_lock,
confl_snapshot,
confl_bufferpin,
confl_deadlock
FROM pg_stat_database_conflicts;
判断顺序:
- 是查询被取消,还是 replay lag 增大?
- 是否开启
hot_standby_feedback? - Primary 的复制
backend_xmin是否长期停滞? - 同期 Primary 表和索引是否膨胀?
- 该副本的角色是 HA 低延迟副本,还是可容忍延迟的报表副本?
不要让一个副本同时承担“最快故障接管”和“无限长 BI 查询”两个互相冲突的目标。
13.8 可在线执行的低风险动作
- 查询系统视图、锁、复制和 VACUUM 统计;
- 暂停非关键批任务或入口流量;
- 对明确的只读长查询先使用
pg_cancel_backend(pid); - 联系应用让事务正常提交/回滚;
- 在旧 Snapshot 结束后,对目标表执行普通
VACUUM (VERBOSE, ANALYZE),并监控 I/O; - 为未来会话按角色设置合适 timeout;
- 暂时降低报表并发,让关键 OLTP 获得连接和 I/O。
13.9 高风险动作
pg_terminate_backend():会中止事务并释放锁,可能触发业务重试风暴和后续垃圾清理压力;VACUUM FULL:需要强锁并重写表,不是日常在线止损工具;- 大表 REINDEX/重写 DDL:需要评估锁、磁盘和 WAL;
- 全局关闭 autovacuum、
fsync或full_page_writes; - 为减少 Standby 查询取消而无限制开启 feedback 或延迟回放;
- 磁盘接近满时直接执行需要额外空间的重写操作;
- 看到 Pool 排队就无上限增加连接数。
13.10 临时止损、根本修复与验证
临时止损
- 找到最老且非必要的 Snapshot/锁持有者,优先让所有者正常结束。
- 无法协调时,先取消查询,再经影响确认后终止 backend。
- 暂停高频 UPDATE/DELETE 的非关键作业和无抖动重试。
- 磁盘紧张时先扩容或安全释放空间,再考虑表维护。
- Standby 冲突时根据业务优先级选择取消报表、容忍延迟或迁移到专用副本。
根本修复
- 缩短事务,只保留必须原子提交的 SQL;
- 把外部服务调用移出事务,使用 Outbox/状态机;
- 按角色设置 idle/transaction/statement timeout;
- 修复连接、Rows、BatchResults 泄漏;
- 大批处理分块提交,减少 Savepoint;
- 提升 HOT 机会并校准 autovacuum;
- 对长报表做节点和资源隔离;
- 为 Commit Unknown 建立幂等键、唯一约束和对账流程。
验证修复
- 最老
xact_age和backend_xmin_age回落; - dead tuple、表/索引大小增长率恢复到预算;
- VACUUM 能实际移除版本,而不是只反复扫描;
- Buffers/call、P95/P99、Pool Acquire 改善;
- 锁等待和
idle in transaction数下降; - Standby conflict、replay lag 和 Primary feedback xmin 符合 SLO;
- 故障注入验证超时、断线和 Failover 后不会产生重复业务副作用。
14. 常见错误与反模式:每个错误都违反了主线中的一个边界
14.1 把“事务已提交”理解成“所有会话立刻可见”
错在哪里:混淆现实事务状态和观察者的历史 Snapshot。 正确做法:同时确认写者是否提交、观察者的隔离级别和 Snapshot 取得时间。
14.2 在 Read Committed 中假设两次 SELECT 结果稳定
错在哪里:忽略 RC 每条语句重新取得 Snapshot。 正确做法:优先改为原子 SQL;确需稳定视图时再评估 RR/Serializable 或显式锁。
14.3 把 xmin 当永久业务版本号
错在哪里:XID 会回绕,复制、恢复和迁移后语义也不稳定。
正确做法:使用显式 version bigint、事件 ID、业务请求 ID 或审计表。
14.4 高频只读监控无条件调用 pg_current_xact_id()
错在哪里:它会强制分配真实 XID。
正确做法:纯观察优先使用 pg_current_xact_id_if_assigned()。
14.5 看到 xmax <> 0 就断言行已删除
错在哪里:xmax 可能是锁、MultiXact、未提交事务或已回滚事务。
正确做法:结合 infomask、事务状态和当前 Snapshot 判断。
14.6 把 MVCC 当成“无锁数据库”
错在哪里:MVCC 主要减少普通读写冲突,写写冲突、DDL、唯一约束和显式行锁仍会等待。 正确做法:区分“寻找可见版本”和“等待另一个事务/锁”。
14.7 在数据库事务内调用慢 HTTP/RPC
错在哪里:外部延迟被放大为连接、锁和 Snapshot 持有时间。 正确做法:外部调用前置或后置;可靠协作使用 Outbox、Saga、状态机和幂等键。
14.8 忽略 Commit 错误,或把所有 Commit 错误都当作未提交
错在哪里:传输错误可能发生在服务器已经提交之后。 正确做法:单独检查 Commit;按业务请求 ID查询结果;不盲目重放副作用。
14.9 Repository 私自 BEGIN/COMMIT
错在哪里:把一个业务原子操作切成多个不可一起回滚的数据库事务。
正确做法:由 Service 统一开启事务,并把同一个 pgx.Tx 传给所有 Repository。
14.10 只设置 statement_timeout
错在哪里:语句结束后,事务仍可能长期处于 idle in transaction。
正确做法:同时治理事务总时长、事务内空闲时间和客户端生命周期。
14.11 每行一个 Savepoint
错在哪里:制造大量 subxid、pg_subtrans 访问、状态管理和失败后垃圾清理成本。
正确做法:集合式 SQL、staging、分批提交,只在真正需要局部恢复时使用少量 Savepoint。
14.12 dead tuple 增长就反复执行 VACUUM FULL
错在哪里:若旧 Snapshot 仍存在,普通 VACUUM 不能越过安全边界;VACUUM FULL 还会强锁和重写表。 正确做法:先找到阻止 horizon 推进的原因,再选择普通 VACUUM、在线重建或维护窗口。
14.13 为保留旧版本而关闭 autovacuum
错在哪里:会同时加重膨胀、索引垃圾和 XID Wraparound 风险。 正确做法:使用受控实验表或物理工具观察,生产保持 autovacuum 健康。
14.14 无限制持有导出 Snapshot
错在哪里:导出协调者长期开放会钉住清理地平线。 正确做法:为 worker 设置截止时间、失败清理和整批重建协议。
14.15 为消除 Standby 查询取消,无条件开启 hot_standby_feedback
错在哪里:只是把成本从副本查询取消转移到 Primary 膨胀。 正确做法:同时定义允许的查询时长、replay lag、Primary 空间增长和副本职责。
14.16 Pool 排队就不断增大连接数
错在哪里:数据库可能已经因长事务、锁、I/O 或 CPU 饱和;增加连接只会增加排队位置。 正确做法:联合观察 Acquire 延迟、活跃查询、Wait Event、xact age 和数据库资源,再设置有界并发。
15. 两个模拟生产事故:用同一因果链复盘
15.1 事故一:只读对账任务拖慢 Primary
系统背景
订单 Primary 承载高频 OLTP。夜间对账程序开启 Repeatable Read,先扫描订单,再逐条调用外部结算 API。开发团队认为“只读事务不会影响写入”。
故障现象
- 批处理运行数小时后,订单表和索引持续增长;
- autovacuum 一直运行,但 dead tuple 不明显下降;
- 查询 Buffers/call、I/O 和 P99 上升;
- pgxpool Acquire 等待增加;
- 没有明显的普通 SELECT 与 UPDATE 行锁冲突。
用四问法排查
Q1 谁产生版本? 订单状态持续 UPDATE,旧版本产生速率正常但较高。
Q2 谁拿着旧 Snapshot? 对账连接的 RR 事务已运行 5 小时,backend_xmin 很老。
Q3 谁结束版本? 正常 OLTP UPDATE 已提交,旧版本对新查询已失效。
Q4 为什么不能回收? 对账 Snapshot 仍可能读取批处理开始时的旧订单版本。
应用 trace 进一步显示:每处理一行都在数据库事务内等待外部 API,偶发几十秒超时。
根因链
RR 首次查询固定旧 Snapshot
-> 事务内逐条外部 RPC
-> Snapshot 持续数小时
-> VACUUM 不能移除后续 UPDATE 的旧版本
-> Heap/Index 膨胀
-> Buffer/I/O/P99/Pool Acquire 恶化
临时止损
- 暂停对账入口;确认任务可重跑后结束该事务;
- 为关键 OLTP 保留连接和 I/O;
- Snapshot 释放后执行普通 VACUUM 并监控 I/O;
- 磁盘接近容量线时先扩容,不在高峰直接执行 VACUUM FULL。
最终修复
- 把外部结算调用移出数据库事务;
- 按稳定主键分页,每批使用短 RC 事务;
- 使用业务请求 ID 和 Outbox 驱动后续处理;
- 长报表迁移到专用副本或分析系统;
- 为批处理角色设置事务总时长和 idle timeout;
- 监控按
application_name聚合的最老backend_xmin。
面向三个维度的结论
- 高性能:问题不是 SELECT 本身慢,而是 Snapshot 延长了旧版本寿命。
- 高并发:连接被长事务占用,即使没有显式锁队列也会造成 Pool 排队。
- 高可用:更大数据量和 WAL 会延长备份、恢复和副本追赶时间。
15.2 事故二:开启 feedback 后 Standby 稳了,Primary 却膨胀
系统背景
物理 Standby 提供 BI 查询。分析师查询经常运行 30—90 分钟,并因 confl_snapshot 被取消。团队全局开启 hot_standby_feedback=on,但未建立 Primary 容量监控。
故障现象
- Standby 查询取消明显减少;
- 数天后 Primary 大表和索引持续增长;
- autovacuum 正常启动,但回收效果差;
- Primary I/O、Checkpoint 和备份窗口增长;
- 报表运行期间 replay lag 波动。
用四问法排查
Q1 谁产生版本? Primary 上高更新表持续产生旧版本。
Q2 谁拿着旧 Snapshot? Standby 长查询持有恢复 Snapshot。
Q3 谁结束版本? Primary UPDATE/DELETE 已正常提交。
Q4 为什么不能回收? Standby 把 xmin 需求反馈给 Primary,复制 backend_xmin 长期停滞。
本地 pg_stat_activity 没有同等年龄的 backend_xmin,而 pg_stat_replication.backend_xmin 与报表时间高度相关。
根因链
Standby 长查询
-> hot_standby_feedback 反馈旧 xmin
-> Primary 为副本保留旧版本
-> Standby 冲突下降
-> Primary 膨胀、I/O、WAL、备份时间上升
临时止损
- 暂停最重报表并限制 BI 并发;
- 等反馈 xmin 前进后逐表普通 VACUUM;
- 容量不足时先扩容并安排在线维护窗口;
- 重新评估该副本是否必须保持低 replay lag。
最终修复
- HA 副本与报表副本分离职责;
- 报表副本允许更大延迟,但设置查询时长和资源预算;
- 超长分析迁移到数仓或增量物化层;
- 为 feedback 定义可量化护栏:Primary 最大膨胀、最大反馈 xmin age、最大 replay lag;
- 复制参数变更必须同时说明“成本被转移到哪个节点和哪个 SLO”。
面向三个维度的结论
- 高性能:副本查询便利最终由 Primary 空间和 I/O 买单。
- 高并发:报表并发越高,恢复 Snapshot 越难推进。
- 高可用:HA 副本若被报表拖慢,故障接管时的数据新鲜度和 RTO 都可能恶化。
16. 面试题:始终沿“产生—观察—结束—回收”回答
回答本章面试题时,避免只背定义。一个高质量答案通常包含四层:
- 先给 30 秒结论;
- 用 Tuple 与 Snapshot 解释原理;
- 给出性能/并发/可用性影响;
- 说明生产诊断或代码设计。
16.1 XID、Full XID 与 VXID 有什么区别?
30 秒回答:事务开始后先有 VXID,用于 backend 当前运行期内的事务身份和锁协调,不消耗全局 XID。事务首次写入或显式请求时获得 32 位 XID,并把它写入 Tuple。Full XID 把 epoch 与 XID 组合为 xid8,用于跨回绕窗口表达更完整的顺序。
深入回答:只读事务可能一直没有真实 XID;pg_current_xact_id() 会强制分配,pg_current_xact_id_if_assigned() 不会。Tuple 系统列仍是会回绕的 xid,所以不能当永久业务 ID。
常见错误:“每次 BEGIN 都立刻获得一个永不重复的 64 位事务号。”
追问:为什么高频监控不应无条件调用 pg_current_xact_id()?
回答:它会让本可保持 VXID-only 的只读事务分配真实 XID,增加无意义的 XID 消耗。
16.2 INSERT、UPDATE、DELETE 分别如何改变 Heap Tuple?
30 秒回答:INSERT 创建新版本并写 xmin;UPDATE 给旧版本写 xmax,同时创建 xmin 为更新事务的新版本;DELETE 只结束当前版本,通常不创建“删除后的空版本”。
深入回答:旧版本的 ctid 通常指向更新链后继。满足 HOT 条件时可减少索引维护,但 Heap 新版本仍存在,仍受 Snapshot 和 VACUUM 地平线约束。
常见错误:“UPDATE 原地覆盖,DELETE 立即释放空间。”
追问:HOT 能否彻底消除膨胀? 回答:不能。它主要减少索引新条目和改善链内清理,Heap 版本仍会产生。
16.3 Snapshot 的 xmin、xmax 和 xip 分别表示什么?
30 秒回答:Snapshot xmin 是取得 Snapshot 时仍可能活跃的最小顶层事务下界;xmax 是下一个尚未分配的 XID;xip 是位于边界内、取得 Snapshot 时仍活跃的顶层事务集合。
深入回答:X < xmin 表示当时已结束,但还要判断 committed/aborted;X >= xmax 属于未来;边界内且在 xip 中表示当时仍活跃;边界内不在 xip 中表示当时已经结束。
常见错误:把 Snapshot xmin/xmax 当作某行的创建和删除事务。
追问:为什么 Snapshot 不是一个普通时间戳? 回答:并发事务的开始、首次写入和提交顺序可能不同,单个时间戳不足以表达取得 Snapshot 时哪些事务仍活跃。
16.4 如何判断一个 Tuple 对某个 Snapshot 是否可见?
30 秒回答:先判断 xmin 的创建是否对 Snapshot 已生效,再判断 xmax 的结束是否对 Snapshot 已生效。创建有效且结束尚未有效时,该版本可见;当前事务还要结合 CID。
深入回答:xmin 在 xip 中、>= snapshot.xmax 或 aborted 时不可见;创建已提交后再看 xmax。若 xmax 无效、仅锁、aborted、在 xip 中或属于未来,旧版本仍可见;若有效删除事务在 Snapshot 前提交,则不可见。
常见错误:“xmin committed 就一定可见,xmax 非零就一定不可见。”
追问:为什么必须先判断 xmin?
回答:一个从未有效创建的版本没有资格进入“是否已被结束”的判断。
16.5 为什么事务 A 已提交,事务 B 仍可能看不到 A 的数据?
30 秒回答:若 B 使用在 A 提交前取得的 RR/Serializable Snapshot,A 在该 Snapshot 中属于活跃或未来事务。A 后来提交改变现实状态,但不会改写 B 的历史观察点。
深入回答:事务状态和 Snapshot 视角是两层信息。pg_xact_status(A) 可以显示 committed,但 B 的可见性判断仍依据取得 Snapshot 时的 xip/xmax。
常见错误:“COMMIT 会广播刷新所有会话缓存。”
追问:B 什么时候能看到 A? 回答:B 结束旧事务并取得新 Snapshot;在 RC 中则通常是下一条语句取得新 Snapshot。
16.6 Command ID 为什么必要?
30 秒回答:同一事务的多条命令共享一个 XID,仅靠 XID 无法区分自己的写入发生在当前扫描之前还是之后。CID 让后续命令看到先前写入,同时避免当前扫描看到扫描过程中后来产生的版本。
深入回答:Tuple 头 t_cid 按状态解释为 cmin/cmax,复杂情况使用 Combo CID;Snapshot 的 curcid 提供命令级观察边界。
常见错误:“当前事务总能看到自己所有写入,不需要额外顺序信息。”
追问:这与 Halloween Problem 有什么关系? 回答:稳定的命令观察边界可避免扫描过程中产生的新版本重新倒灌进同一命令并被重复处理。
16.7 pg_xact 与 Hint Bits 各自做什么?
30 秒回答:pg_xact 保存近期事务的权威最终状态;Hint Bits 把已经判断出的提交/回滚结果缓存在 Tuple 头,减少后续重复查询 pg_xact。
深入回答:COMMIT 不会遍历并修改该事务写过的所有 Tuple。后续读取者在需要时查询事务状态并设置 Hint Bits,这避免提交时的巨大随机写放大。
常见错误:“Hint Bit 就是事务提交记录,丢失它会丢数据。”
追问:为什么一个刚提交的事务创建的 Tuple 不一定立即带 committed Hint Bit? 回答:Hint Bit 通常由后续访问路径按需设置,COMMIT 的权威记录在事务状态/WAL 体系中。
16.8 Read Committed 与 Repeatable Read 的核心区别是什么?
30 秒回答:核心区别是 Snapshot 生命周期。RC 每条语句取得新 Snapshot;RR 在第一个非事务控制语句时取得事务级 Snapshot,之后复用。
深入回答:RC 两次 SELECT 可以看到中间提交;RR 不会。RR 修改一个在其 Snapshot 之后已被其他事务提交修改的行时可能报 40001。RR 仍可能存在序列化异常,不等于 Serializable。
常见错误:“只要显式 BEGIN,RC 的视图也固定。”
追问:把所有业务改成 RR 是否更安全? 回答:不一定。它延长 Snapshot 生命周期、增加旧版本保留,并需要处理并发更新失败;业务不变量还可能要求 Serializable 或锁。
16.9 MVCC 为什么能减少阻塞,又为什么不能消除锁?
30 秒回答:读者可以读取写者修改前的旧版本,所以普通 SELECT 和 UPDATE 通常不互相阻塞;但两个写者修改同一逻辑行时必须决定唯一的版本顺序,仍需要行锁和事务等待。
深入回答:显式 FOR UPDATE、DDL、唯一约束、外键检查和事务 ID 锁都可能阻塞。排障时先判断当前过程是在选择可见版本,还是在等待另一个事务结束。
常见错误:“PostgreSQL 使用 MVCC,所以没有行锁。”
追问:RC 写者等待另一个写者提交后会发生什么?
回答:可能在新版本上重新检查 WHERE 条件并继续;RR 则可能因为 Snapshot 过期而报 40001。
16.10 为什么只读长事务也会阻止 VACUUM?
30 秒回答:只读长事务虽然不产生新版本,但它的旧 Snapshot 可能仍需要其他事务后来淘汰的版本。VACUUM 必须保留这些版本,直到该 Snapshot 离开。
深入回答:长事务通过 backend_xmin 参与清理地平线。旧版本保留会扩大 Heap/Index、降低缓存有效性、增加扫描 Page、VACUUM、WAL 和 P99。
常见错误:“只读事务没有写锁,所以对写库没有影响。”
追问:如何证明某个事务正在阻止清理?
回答:关联 pg_stat_activity.backend_xmin、事务年龄、目标表 dead tuple/size、VACUUM 日志,并排除复制 feedback、slot 和 prepared transaction 等其他 horizon。
16.11 Savepoint 很多为什么会变慢?
30 秒回答:写子事务会获得 subxid 并维护父子关系。backend 快速缓存容量有限,超过约 64 个活跃 subxid 后,其他 backend 的可见性判断更可能访问 pg_subtrans,同时事务状态管理和失败后垃圾清理成本也会增加。
深入回答:RELEASE SAVEPOINT 不是独立提交,只是合并进父事务。PL/pgSQL EXCEPTION 块也可能隐式创建子事务。
常见错误:“Savepoint 只是客户端记一个名字,没有服务端成本。”
追问:批量导入如何替代每行 Savepoint? 回答:先写 staging table,再用集合式校验/合并;或分批提交,把失败行单独记录。
16.12 如何设计并行一致性导出?
30 秒回答:协调者开启短生命周期 RR 只读事务并导出 Snapshot;worker 各自开启 RR 只读事务,在第一条查询前导入同一 Snapshot,再按互不重叠范围读取。协调者必须保持开放,任一关键节点失败则整批重建。
深入回答:共享的是导出时的历史视图,不是各 worker 的未提交写入。导出者会钉住 xmin,应设置总时长、worker 健康检查、manifest、分片校验和和失败清理。
常见错误:“所有 worker 差不多同时 BEGIN 就足够一致。”
追问:导出者中途崩溃怎么办? 回答:Snapshot 失效,不能混合旧、新视图继续拼接,应整批重新建立一致观察点。
16.13 MVCC 如何从性能优势演变成性能问题?
30 秒回答:优势是普通读写并行;代价是 UPDATE/DELETE 留下旧版本。Snapshot 越长,旧版本保留越多,查询需要读取和判断的 Page 越多,最终放大 CPU、Buffer、I/O、WAL、Checkpoint 和尾延迟。
深入回答:Index Scan 可能命中旧索引项再回表;Seq Scan 读更多膨胀 Page;HOT 只能减少部分索引维护。平均延迟可能暂时稳定,而 P95/P99 先恶化。
常见错误:“开启 AIO 或增大 shared_buffers 就能解决长事务膨胀。”
追问:为什么 Buffer 命中率高仍可能很慢? 回答:系统可能在内存中高命中地读取大量无效 Page;应看 Buffers/call、rows/call 和物理尺寸,而不只看命中率。
16.14 高并发下如何判断应该增加连接还是限流?
30 秒回答:不能只看 Pool 是否排队。要同时看数据库活跃查询、CPU/I/O、锁等待、事务年龄和 Pool Acquire 时间。若数据库已在锁或资源瓶颈上,增加连接只会扩大队列,应做 Backpressure。
深入回答:goroutine、连接、活跃查询、TPS 和排队请求是不同指标。推荐入口限流、业务 semaphore、有限 pgxpool、数据库 timeout 的分层准入控制。
常见错误:“1000 个并发请求就配置 1000 个数据库连接。”
追问:CPU 只有 40%,为什么 Pool 仍可能排队? 回答:连接可能被长事务、锁等待、外部 RPC、网络或未关闭 Rows 占用,瓶颈不一定是 CPU。
16.15 Standby 长查询为什么会被取消?hot_standby_feedback 有什么代价?
30 秒回答:Standby 必须回放 Primary 的 VACUUM 清理记录,但旧 Recovery Snapshot 可能仍需要相关版本。等待超过预算后,为推进回放会取消查询。feedback 可减少取消,但会让 Primary 延迟清理并膨胀。
深入回答:这是查询可用性、复制新鲜度和 Primary 存储成本的三角取舍。HA 副本和报表副本最好分离职责。
常见错误:“副本只读,所以 VACUUM 与副本查询无关。”
追问:如何证明 Primary 膨胀来自 feedback?
回答:比较本地 backend_xmin、pg_stat_replication.backend_xmin、报表时间线和表大小;本地无老 xmin 而复制反馈长期停滞时证据更强。
16.16 如何设计包含外部支付调用的业务事务?
30 秒回答:不要让 PostgreSQL 事务跨越外部支付 RPC。先在本地短事务中创建带幂等键的支付意图并提交,再调用外部系统,通过回调、轮询或 Outbox 更新最终状态。
深入回答:数据库只能保证本地原子性,外部系统不在同一提交域。状态机应明确 pending、unknown、succeeded、failed,并能处理超时、重复回调和人工对账。
常见错误:“BEGIN 后调用支付 API,成功 COMMIT、失败 ROLLBACK,就实现了全局原子。”
追问:外部支付成功,但本地确认事务超时怎么办? 回答:按支付请求 ID 查询双方状态并幂等补确认,不能再次无条件扣款。
16.17 如何处理 Commit 结果不确定?
30 秒回答:所有有副作用请求都使用全局幂等键并由数据库唯一约束保护。COMMIT 传输错误时先查询该键对应的业务结果,不直接重复执行副作用。
深入回答:服务器明确 SQLSTATE、pgx.ErrTxCommitRollback 和网络传输错误的确定性不同。Failover 时还要判断旧 Primary 的提交是否复制到新 Primary。pg_xact_status() 只能辅助近期同集群判断,业务账本才是长期真相。
常见错误:“Commit 返回 error 就说明没有提交,可以安全重试。”
追问:幂等键已存在但请求参数不同怎么办? 回答:比较请求参数摘要;相同请求返回既有结果,不同请求应报幂等键冲突并告警,而不是覆盖原记录。
17. 练习与检查清单
17.1 理论练习
练习 1
事务 A 在事务 B 的 RR Snapshot 建立时仍活跃,随后 A 提交。为什么 B 仍看不到 A 插入的行?
参考答案:B 的 Snapshot 已经把 A 记录为当时活跃或未来事务。A 后来提交只改变现实事务状态,不改变 B 的历史观察点。B 结束事务并取得新 Snapshot 后才能看到。
练习 2
一个 Tuple 的 xmin 已 committed,是否足以证明它可见?
参考答案:不足。还要确认该事务不是 Snapshot 的活跃/未来事务,并继续判断 xmax 是否已对该 Snapshot 生效。若属于当前事务,还要使用 CID。
练习 3
为什么回滚大型 INSERT 后,表文件不会立即缩小?
参考答案:回滚首先把事务标为 aborted,使插入版本不可见;Tuple 仍可能物理存在,等待 VACUUM 标记并让内部空间复用。普通 VACUUM 也不保证缩小文件到操作系统。
练习 4
idle 与 idle in transaction 的本质区别是什么?
参考答案:前者当前没有开放事务;后者事务仍开放,可能持锁、Snapshot、XID、未提交写入和连接槽位。实际影响要检查 backend_xid/backend_xmin 和锁。
练习 5
为什么 hot_standby_feedback 能减少查询取消,却可能伤害 Primary?
参考答案:它把 Standby 长 Snapshot 所需的 xmin 反馈给 Primary,Primary 因而延迟回收旧版本。副本冲突减少,但主库空间、I/O、WAL 和维护成本可能上升。
17.2 实验练习
练习 1:当前事务自己的写入
在一个事务中依次执行 INSERT、SELECT、DELETE、SELECT,记录 xmin/xmax/cmin/cmax,解释两次 SELECT。
参考答案要点:两次写入共享顶层 XID;第一次 SELECT 是后续命令,能看到此前 INSERT;DELETE 后的第二次 SELECT 看不到该版本。差异来自 CID 与 curcid。
练习 2:回滚 UPDATE
Session A 固定 RR Snapshot;Session B UPDATE 后 ROLLBACK;分别在 A 和新 RC 会话查询,并用 pageinspect 观察。
参考答案要点:A 始终看到旧版本;B 回滚后新 RC 也看到旧版本。新版本的 xmin aborted,旧版本的 xmax 也因更新事务 aborted 而无效。物理垃圾等待 VACUUM。
练习 3:导出 Snapshot
实现一个导出者和两个导入者,分别统计奇数/偶数主键。在导出后插入新行并提交,验证两个 worker 都不包含新行。
参考答案要点:导出者保持 RR 只读事务开放;worker 在第一条查询前导入同一 Snapshot;后提交的新行对所有 worker 都不可见。导出者失败则整批重建。
17.3 排障练习
练习 1
n_dead_tup 持续增长,autovacuum 每分钟都在运行。如何排查?
参考答案要点:先查最老 backend_xmin、复制 feedback、复制槽、Prepared Transaction;再看写入速率是否超过清理能力、VACUUM 日志、表级参数、I/O、HOT 比例和索引。不能仅凭“autovacuum 已运行”认定它有能力回收。
练习 2
应用 P99 上升,数据库 CPU 只有 45%,但 pgxpool Acquire P99 很高。如何判断是否与事务有关?
参考答案要点:查看连接持有时长、xact_start、idle in transaction、锁等待和 Wait Event;关联应用 span,检查事务内外部 RPC、Rows/Batch 未关闭和连接泄漏。CPU 不满不代表连接没有被等待占用。
17.4 系统设计练习
设计一个“每天导出 20 亿行、要求同一业务时点、不能显著影响 Primary VACUUM”的系统。
参考答案要点:
- 优先在专用物理副本或独立分析节点执行;
- 协调者导出 Snapshot,worker 按互不重叠范围导入;
- 为单批 Snapshot 设置最大持续时间,按稳定分区或时间窗口分批;
- 输出 manifest、Snapshot/LSN、分片范围、行数和校验和;
- 导出者失败时整批作废,不混合不同视图;
- 监控 replay lag、Recovery Conflict、feedback xmin 和 Primary bloat;
- 长期使用 CDC + 周期校准,减少每天全量扫描;
- 故障转移后幂等重建任务和产物。
17.5 最终检查清单
版本产生
- 我能区分 VXID、XID、Full XID、subxid 和 CID。
- 我能画出 INSERT、UPDATE、DELETE 对 Tuple
xmin/xmax/ctid的影响。 - 我知道
xmax可能表示锁或已回滚事务,不能机械解释。 - 我知道
pg_xact与 Hint Bits 的职责边界。
Snapshot 与可见性
- 我能解释 Snapshot
xmin/xmax/xip。 - 我能按“先创建、后结束”的顺序判断 Tuple 可见性。
- 我能解释旧 Snapshot 为什么看不到后来提交的事务。
- 我能解释当前事务自己的写入和 Command ID。
- 我能复现 RC 语句级 Snapshot 与 RR 事务级 Snapshot。
回收与性能
- 我能解释 live、recently dead 和 removable 的区别。
- 我能用
backend_xmin定位长 Snapshot。 - 我能说明长事务如何放大 Heap/Index、Buffer、I/O、WAL 和 P99。
- 我知道 HOT 能减少索引维护,但不能取消 Heap 版本。
高并发
- 我能区分 MVCC 版本选择与锁等待。
- 我能说明热点行、subxid、连接池排队和重试风暴。
- 我知道
40001/40P01需要重试完整事务。 - 我能设计有界连接池和 Backpressure。
高可用
- 我能解释 Primary 可见、WAL 持久、Standby 回放是不同边界。
- 我能解释 Recovery Conflict 和
hot_standby_feedback的取舍。 - 我能说明逻辑复制不会保留源端物理
xmin/xmax业务语义。 - 我能处理 Failover 与 Commit Unknown。
Go/pgx
- 我能使用
BeginTx明确事务选项。 - 我能使用
defer Rollback覆盖错误和 Panic 路径。 - 我会独立检查 Commit 错误,不把网络错误直接当作未提交。
- 我不会在事务中调用慢外部服务。
- 我不会让 Repository 随意切断业务事务边界。
- 我会使用业务幂等键和唯一约束,而不是系统 XID。
18. 官方资料来源
- PostgreSQL 18:Introduction to MVCC
- PostgreSQL 18:Transaction Isolation
- PostgreSQL 18:Transactions and Identifiers
- PostgreSQL 18:System Columns
- PostgreSQL 18:Transaction ID and Snapshot Information Functions
- PostgreSQL 18:Snapshot Synchronization Functions
- PostgreSQL 18:SET TRANSACTION
- PostgreSQL 18:Monitoring Database Activity / pg_stat_activity
- PostgreSQL 18:Database Page Layout
- PostgreSQL 18:pageinspect
- PostgreSQL 18:Routine Vacuuming
- PostgreSQL 18:Hot Standby
- PostgreSQL 18:Subtransactions
- PostgreSQL 18:Client Connection Defaults and Timeouts
- PostgreSQL 18:Release Notes
- PostgreSQL Source:heapam_visibility.c
- PostgreSQL Source:snapshot.h
- PostgreSQL Source:clog.c / pg_xact
- pgx/v5 官方 API
- pgxpool 官方 API